package org.force4spring.support;

import java.rmi.RemoteException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Calendar;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;

import javax.xml.namespace.QName;
import javax.xml.soap.SOAPException;

import org.apache.axis.message.MessageElement;
import org.apache.commons.beanutils.DynaBean;
import org.apache.commons.beanutils.DynaProperty;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.dao.PermissionDeniedDataAccessException;
import org.springframework.dao.TypeMismatchDataAccessException;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;

import com.sforce.soap.partner.DeleteResult;
import com.sforce.soap.partner.DescribeGlobalResult;
import com.sforce.soap.partner.DescribeLayoutResult;
import com.sforce.soap.partner.DescribeSObjectResult;
import com.sforce.soap.partner.DescribeSoftphoneLayoutResult;
import com.sforce.soap.partner.DescribeTabSetResult;
import com.sforce.soap.partner.Email;
import com.sforce.soap.partner.Field;
import com.sforce.soap.partner.GetDeletedResult;
import com.sforce.soap.partner.GetServerTimestampResult;
import com.sforce.soap.partner.GetUpdatedResult;
import com.sforce.soap.partner.GetUserInfoResult;
import com.sforce.soap.partner.LeadConvert;
import com.sforce.soap.partner.LeadConvertResult;
import com.sforce.soap.partner.MergeRequest;
import com.sforce.soap.partner.MergeResult;
import com.sforce.soap.partner.ProcessRequest;
import com.sforce.soap.partner.ProcessResult;
import com.sforce.soap.partner.QueryResult;
import com.sforce.soap.partner.ResetPasswordResult;
import com.sforce.soap.partner.SaveResult;
import com.sforce.soap.partner.SearchResult;
import com.sforce.soap.partner.SendEmailResult;
import com.sforce.soap.partner.SetPasswordResult;
import com.sforce.soap.partner.SoapBindingStub;
import com.sforce.soap.partner.UndeleteResult;
import com.sforce.soap.partner.UpsertResult;
import com.sforce.soap.partner.fault.ApiFault;
import com.sforce.soap.partner.fault.InvalidFieldFault;
import com.sforce.soap.partner.fault.InvalidIdFault;
import com.sforce.soap.partner.fault.InvalidNewPasswordFault;
import com.sforce.soap.partner.fault.InvalidQueryLocatorFault;
import com.sforce.soap.partner.fault.InvalidSObjectFault;
import com.sforce.soap.partner.fault.MalformedQueryFault;
import com.sforce.soap.partner.fault.MalformedSearchFault;
import com.sforce.soap.partner.fault.UnexpectedErrorFault;
import com.sforce.soap.partner.sobject.SObject;

/**
 * 1) Batching support 2) Caching support
 * 
 * @author Max Rudman
 */
public class ForceSessionImpl implements ForceSession {
	private SoapBindingStub binding;
	private ForceCredentials credentials;
	private boolean autoFlush = false;
	private String defaultNamespace;
	private Map<String, DescribeSObjectResult> metadataCache;
	
	private GetUserInfoResult userInfo;

	public ForceSessionImpl(SoapBindingStub binding,
			ForceCredentials credentials, String defaultNamespace) {
		this.binding = binding;
		this.credentials = credentials;
		this.defaultNamespace = defaultNamespace;
		if (defaultNamespace != null) {
			this.defaultNamespace = defaultNamespace.toLowerCase();
		}
		metadataCache = new HashMap<String, DescribeSObjectResult>();
	}

	public boolean isOpen() {
		return (binding != null);
	}

	public void clear() {
		// TODO: Clear the batch
	}

	public void flush() throws RemoteException, UnexpectedErrorFault {
		// TODO: Make WS calls to submit the batch.
	}

	/**
	 * @see org.force4spring.support.ForceSession#setAutoFlush(boolean)
	 */
	public void setAutoFlush(boolean autoFlush) {
		this.autoFlush = autoFlush;
	}

	public void close() throws RemoteException, UnexpectedErrorFault {
		binding = null;
	}

	/**
	 * @see com.sforce.soap.partner.SoapBindingStub#convertLead(com.sforce.soap.partner.LeadConvert[])
	 */
	public LeadConvertResult[] convertLead(LeadConvert[] leadConverts)
			throws RemoteException, UnexpectedErrorFault {
		return binding.convertLead(leadConverts);
	}

	/**
	 * @see com.sforce.soap.partner.SoapBindingStub#create(com.sforce.soap.partner.sobject.SObject[])
	 */
	public SaveResult[] create(SObjectDynaBean[] objects)
			throws RemoteException, UnexpectedErrorFault, InvalidSObjectFault,
			InvalidIdFault {
		return binding.create(toSObjects(objects));
	}

	/**
	 * @see com.sforce.soap.partner.SoapBindingStub#delete(java.lang.String[])
	 */
	public DeleteResult[] delete(String[] ids) throws RemoteException,
			UnexpectedErrorFault {
		return binding.delete(ids);
	}

	/**
	 * @see com.sforce.soap.partner.SoapBindingStub#describeGlobal()
	 */
	public DescribeGlobalResult describeGlobal() throws RemoteException,
			UnexpectedErrorFault {
		return binding.describeGlobal();
	}

	/**
	 * @see com.sforce.soap.partner.SoapBindingStub#describeLayout(java.lang.String,
	 *      java.lang.String[])
	 */
	public DescribeLayoutResult describeLayout(String type,
			String[] recordTypeIds) throws RemoteException,
			UnexpectedErrorFault, InvalidSObjectFault {
		return binding.describeLayout(type, recordTypeIds);
	}

	/**
	 * @see com.sforce.soap.partner.SoapBindingStub#describeSObject(java.lang.String)
	 */
	public DescribeSObjectResult describeSObject(String type)
			throws RemoteException, UnexpectedErrorFault, InvalidSObjectFault {
		if (metadataCache.containsKey(type)) {
			return metadataCache.get(type);
		}
		DescribeSObjectResult result = binding.describeSObject(type);
		metadataCache.put(type, result);
		return result;
	}

	/**
	 * @see com.sforce.soap.partner.SoapBindingStub#describeSObjects(java.lang.String[])
	 */
	public DescribeSObjectResult[] describeSObjects(String[] types)
			throws RemoteException, UnexpectedErrorFault, InvalidSObjectFault {
		// DescribeSObjectResult[] result = binding.describeSObjects(types);
		DescribeSObjectResult[] result = new DescribeSObjectResult[types.length];
		for (int i = 0; i < types.length; i++) {
			result[i] = describeSObject(types[i]);
		}
		return result;
	}

	/**
	 * @see com.sforce.soap.partner.SoapBindingStub#describeSoftphoneLayout()
	 */
	public DescribeSoftphoneLayoutResult describeSoftphoneLayout()
			throws RemoteException, UnexpectedErrorFault {
		return binding.describeSoftphoneLayout();
	}

	/**
	 * @see com.sforce.soap.partner.SoapBindingStub#describeTabs()
	 */
	public DescribeTabSetResult[] describeTabs() throws RemoteException,
			UnexpectedErrorFault {
		return binding.describeTabs();
	}

	/**
	 * @see com.sforce.soap.partner.SoapBindingStub#getDeleted(java.lang.String,
	 *      java.util.Calendar, java.util.Calendar)
	 */
	public GetDeletedResult getDeleted(String type, Calendar start, Calendar end)
			throws RemoteException, UnexpectedErrorFault, InvalidSObjectFault {
		return binding.getDeleted(type, start, end);
	}

	/**
	 * @see com.sforce.soap.partner.SoapBindingStub#getServerTimestamp()
	 */
	public GetServerTimestampResult getServerTimestamp()
			throws RemoteException, UnexpectedErrorFault {
		return binding.getServerTimestamp();
	}

	/**
	 * @see com.sforce.soap.partner.SoapBindingStub#getUpdated(java.lang.String,
	 *      java.util.Calendar, java.util.Calendar)
	 */
	public GetUpdatedResult getUpdated(String type, Calendar start, Calendar end)
			throws RemoteException, UnexpectedErrorFault, InvalidSObjectFault {
		return binding.getUpdated(type, start, end);
	}

	/**
	 * @see com.sforce.soap.partner.SoapBindingStub#getUserInfo()
	 */
	public GetUserInfoResult getUserInfo() throws RemoteException,
			UnexpectedErrorFault {
		if (userInfo == null) {
			userInfo = binding.getUserInfo();
		}
		return userInfo;
	}

	/**
	 * @see com.sforce.soap.partner.SoapBindingStub#merge(com.sforce.soap.partner.MergeRequest[])
	 */
	public MergeResult[] merge(MergeRequest[] mergeRequests)
			throws RemoteException, UnexpectedErrorFault, InvalidSObjectFault,
			InvalidIdFault {
		return binding.merge(mergeRequests);
	}

	/**
	 * @see com.sforce.soap.partner.SoapBindingStub#process(com.sforce.soap.partner.ProcessRequest[])
	 */
	public ProcessResult[] process(ProcessRequest[] processRequests)
			throws RemoteException, UnexpectedErrorFault {
		return binding.process(processRequests);
	}

	/**
	 * @see com.sforce.soap.partner.SoapBindingStub#query(java.lang.String)
	 */
	public List query(String soql) throws RemoteException,
			UnexpectedErrorFault, InvalidSObjectFault, MalformedQueryFault,
			InvalidFieldFault {
		QueryResult qr = binding.query(soql);
		SObjectDynaBean[] beans = toDynaBeans(qr.getRecords());
		return new QueryResultList(this, qr, beans);
	}

	/**
	 * @see com.sforce.soap.partner.SoapBindingStub#queryAll(java.lang.String)
	 */
	public List queryAll(String soql) throws RemoteException,
			UnexpectedErrorFault, InvalidSObjectFault, MalformedQueryFault,
			InvalidFieldFault {
		QueryResult qr = binding.queryAll(soql);
		SObjectDynaBean[] beans = toDynaBeans(qr.getRecords());
		return new QueryResultList(this, qr, beans);
	}

	/**
	 * @see com.sforce.soap.partner.SoapBindingStub#queryMore(java.lang.String)
	 */
	public List queryMore(String queryLocator) throws RemoteException,
			UnexpectedErrorFault, InvalidQueryLocatorFault {
		QueryResult qr = binding.queryMore(queryLocator);
		SObjectDynaBean[] beans = toDynaBeans(qr.getRecords());
		return new QueryResultList(this, qr, beans);
	}

	/**
	 * @see com.sforce.soap.partner.SoapBindingStub#resetPassword(java.lang.String)
	 */
	public ResetPasswordResult resetPassword(String userId)
			throws RemoteException, UnexpectedErrorFault, InvalidIdFault {
		return binding.resetPassword(userId);
	}

	/**
	 * @see com.sforce.soap.partner.SoapBindingStub#retrieve(java.lang.String,
	 *      java.lang.String, java.lang.String[])
	 */
	public SObjectDynaBean[] retrieve(String fields, String type, String[] ids)
			throws RemoteException, UnexpectedErrorFault, InvalidSObjectFault,
			InvalidFieldFault {
		return toDynaBeans(binding.retrieve(fields, type, ids));
	}

	/**
	 * @see com.sforce.soap.partner.SoapBindingStub#search(java.lang.String)
	 */
	public SearchResult search(String sosl) throws RemoteException,
			MalformedSearchFault, UnexpectedErrorFault, InvalidSObjectFault,
			InvalidFieldFault {
		return binding.search(sosl);
	}

	/**
	 * @see com.sforce.soap.partner.SoapBindingStub#sendEmail(com.sforce.soap.partner.Email[])
	 */
	public SendEmailResult[] sendEmail(Email[] emails) throws RemoteException,
			UnexpectedErrorFault {
		return binding.sendEmail(emails);
	}

	/**
	 * @see com.sforce.soap.partner.SoapBindingStub#setPassword(java.lang.String,
	 *      java.lang.String)
	 */
	public SetPasswordResult setPassword(String userId, String password)
			throws RemoteException, UnexpectedErrorFault, InvalidIdFault,
			InvalidNewPasswordFault {
		return binding.setPassword(userId, password);
	}

	/**
	 * @see com.sforce.soap.partner.SoapBindingStub#undelete(java.lang.String[])
	 */
	public UndeleteResult[] undelete(String[] ids) throws RemoteException,
			UnexpectedErrorFault {
		return binding.undelete(ids);
	}

	/**
	 * @see com.sforce.soap.partner.SoapBindingStub#update(com.sforce.soap.partner.sobject.SObject[])
	 */
	public SaveResult[] update(SObjectDynaBean[] objects)
			throws RemoteException, UnexpectedErrorFault, InvalidSObjectFault,
			InvalidIdFault {
		return binding.update(toSObjects(objects));
	}

	/**
	 * @see com.sforce.soap.partner.SoapBindingStub#upsert(java.lang.String,
	 *      com.sforce.soap.partner.sobject.SObject[])
	 */
	public UpsertResult[] upsert(String externalIdField,
			SObjectDynaBean[] objects) throws RemoteException,
			UnexpectedErrorFault, InvalidSObjectFault, InvalidIdFault,
			InvalidFieldFault {
		return binding.upsert(externalIdField, toSObjects(objects));
	}

	/**
	 * Factory method to produce instances of <code>SObjectDynaClass</code>
	 * which can in turn be used to produce Metadata-aware instances of
	 * <code>SObjectDynaBean</code> class.
	 */
	public SObjectDynaClass createDynaClass(String type)
			throws RemoteException, UnexpectedErrorFault, InvalidSObjectFault {
		DescribeSObjectResult result = describeSObject(type);
		return new SObjectDynaClass(result);
	}

	public SObjectDynaBean createDynaBean(SObject source)
			throws RemoteException, UnexpectedErrorFault, InvalidSObjectFault {
		// TODO: Clean up this method.
		SObjectDynaClass dynaClass = createDynaClass(source.getType());
		SObjectDynaBean result = null;
		try {
			result = (SObjectDynaBean) dynaClass.newInstance();
		} catch (InstantiationException ie) {
			throw new DataIntegrityViolationException(
					"Error creating DynaBean", ie);
		} catch (IllegalAccessException iae) {
			throw new PermissionDeniedDataAccessException(
					"Insufficient access to create DynaBean", iae);
		}
		
		if (source.getId() != null) {
			result.set(StandardFields.ID.toLowerCase(), source.getId());
		}
		
		MessageElement[] values = source.get_any();
		for (int i = 0; i < values.length; i++) {
			Object value = values[i].getObjectValue();
			String name = values[i].getName();
			String propName = name.toLowerCase();
			String namespacePrefix = "";
			if (defaultNamespace != null) {
				namespacePrefix = defaultNamespace + "__";
			}
			// First, try looking up property with default namespace
			DynaProperty field = dynaClass.getDynaProperty(namespacePrefix + propName);
			if ((field == null) && (namespacePrefix != "")) {
				// If not found and namespace prefix is set, try without one.
				field = dynaClass.getDynaProperty(propName);
			}
			//System.out.println(propName + ":" + value);
			Assert.notNull(field, "No field found for property: " + propName + "; " + values[i]);
			if (value instanceof QueryResult) {
				QueryResult nestedQuery = (QueryResult)value;
				result.set(propName, new QueryResultList(this, nestedQuery, toDynaBeans(nestedQuery.getRecords())));
			} else if (field.isIndexed()) {
				// Handle multi-select picklist values
				String[] vs = new String[0];
				if (values[i].getValue() != null) {
					vs = values[i].getValue().split(";");
				}
				result.set(propName, new ArrayList(Arrays.asList(vs)));
			} else if (value instanceof SObject) {
				// Handle nested SObject from a relationship query
				SObject nestedSO = (SObject) value;
				try {
					DynaBean nestedBean = createDynaBean(nestedSO);
					result.put(propName, nestedBean);
				} catch (Exception e) {
					// TODO: Clean up exception handling here
					throw new RuntimeException(e);
				}
			} else {
				try {
					if (values[i].getValue() == null) {
						result.put(propName, value);
					} else {
						if (field.getType().equals(Date.class)) {
							// There seems to be a bug in Axis 1.4 where dates in yyyy-mm-dd format
							// can't be deserialized properly. So, handle it manually
							String dateValue = values[i].getValue();
							SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd");
							result.put(propName, df.parse(dateValue));
						} else {
							value = values[i].getObjectValue(field.getType());
							result.put(propName, value);
						}
					}
				} catch (Exception e) {
					throw new RuntimeException("Error getting value from "
							+ name + " on " + dynaClass.getName(), e);
				}
			}
		}
		return result;
	}

	protected SObject toSObject(SObjectDynaBean bean) {
		SObject result = new SObject();
		result.setType(bean.getDynaClass().getName());
		List<MessageElement> fields = new ArrayList<MessageElement>();
		Set<String> nullFields = new HashSet<String>();
		Iterator<String> ki = bean.keySet().iterator();
		while (ki.hasNext()) {
			String property = ki.next();
			Object value = bean.get(property);
			if (StandardFields.ID.equals(property)) {
				result.setId((String)value);
				continue;
			}
			
			// Find full Field descriptor; we'll probably need it later.
			Field field = ((SObjectDynaClass)bean.getDynaClass()).getField(property);
			if (field == null) {
				throw new RuntimeException("No field descriptor: " + property);
			}
			
			if (value == null) {
				nullFields.add(property);
			} else {
				MessageElement element = new MessageElement(new QName(property));

				try {
					if (value instanceof SObjectDynaBean) {
						SObjectDynaBean nestedBean = (SObjectDynaBean)value;
						if (nestedBean.containsKey(StandardFields.ID)) {
							String nestedId = (String)nestedBean.get(StandardFields.ID);
							// If nested bean has ID property 
							// we should set the corresponding field to that ID
							// First, re-create the element with field instead of relationship name
							element = new MessageElement(new QName(field.getName()));
							// And set the value
							element.setValue(nestedId);
						} else {
							// If no Id is set, go ahead and convert as a relationship
							// hoping there is a field marked as "External ID"
							element.setObjectValue(toSObject((SObjectDynaBean) value));
						}
					} else {
						element.setObjectValue(value);
					}
				} catch (SOAPException se) {
					throw new TypeMismatchDataAccessException(
							"Error converting field value to SOAP", se);
				}
				
				// Finally, check to make sure this field is either updateable or createable
				if (field.isUpdateable() || field.isCreateable()) {
					fields.add(element);
				}
			}
		}
		if (nullFields.size() > 0) {
			result.setFieldsToNull(nullFields.toArray(new String[nullFields
					.size()]));
		}
		result.set_any(fields.toArray(new MessageElement[fields.size()]));
		return result;
	}

	protected SObject[] toSObjects(SObjectDynaBean[] in)
			throws RemoteException, ApiFault {
		SObject[] sos = new SObject[in.length];
		for (int i = 0; i < in.length; i++) {
			sos[i] = toSObject(in[i]);
		}
		return sos;
	}

	protected SObjectDynaBean[] toDynaBeans(SObject[] in)
			throws RemoteException, ApiFault {
		if (in == null) {
			return new SObjectDynaBean[0];
		}

		SObjectDynaBean[] beans = new SObjectDynaBean[in.length];
		for (int i = 0; i < in.length; i++) {
			beans[i] = createDynaBean(in[i]);
			Assert.notNull(beans[i], "createDynaBean returned a null bean for " + in[i]);
		}
		return beans;
	}

	/**
	 * Returns value of 'credentials' property
	 * 
	 * @return credentials property
	 */
	public ForceCredentials getCredentials() {
		return credentials;
	}

	/**
	 * Sets value of 'credentials' proprty to specified value.
	 * 
	 * @param credentials
	 *            New value.
	 */
	public void setCredentials(ForceCredentials credentials) {
		this.credentials = credentials;
	}
}
